feat(nodectl): add TONCore Nominator Pool support#39
feat(nodectl): add TONCore Nominator Pool support#39Keshoid merged 50 commits intorelease/nodectl/v0.4.0from
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds support for the TON Nominator Pool (also called TONCore pool or pool-core) to the node control system. Previously, only Single Nominator Pools (SNP) were supported. The implementation includes contract wrappers for pool interaction, message builders for pool operations, configuration support with optional deployment parameters, and CLI commands for pool management.
Changes:
- Added
NominatorPoolWrapperImplwrapper for interacting with deployed TON Nominator Pools - Implemented pool contract state initialization with support for configurable deployment parameters (max nominators, min stakes)
- Added message builders for pool operations (accept coins, process withdrawals, update validator set, etc.)
- Extended configuration to support TONCore pools with optional deploy-time parameters
- Updated deployment commands to support deploying TON Nominator Pools alongside SNP
- Added configuration commands for managing core pools in the CLI
- Updated documentation with TONCore pool configuration details
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/node-control/contracts/src/ton_core_nominator/ | New module implementing nominator pool wrapper and message builders with comprehensive tests |
| src/node-control/service/src/runtime_config.rs | Added support for opening TONCore pools during runtime configuration |
| src/node-control/common/src/app_config.rs | Extended PoolConfig enum with TONCore variant including optional deployment parameters, with serialization tests |
| src/node-control/commands/src/commands/nodectl/deploy_cmd.rs | Updated pool deployment command to handle both SNP and TONCore pools |
| src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs | Added TONCore pool support for manual stake operations |
| src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs | Added CLI commands for managing core pools with dedicated configuration options |
| src/node-control/contracts/src/lib.rs | Exported new nominator pool types and functions |
| src/node-control/README.md | Updated documentation for TONCore pool configuration |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Made-with: Cursor
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (1)
src/node-control/elections/src/runner.rs:945
recover_stake()usespool_addr_cache/stake_addr()which resolves topool.address()(forTonCoreNominatorRouterthis is always the first configured pool). If staking was routed to the other TONCore slot (e.g. pool[1] free, pool[0] busy), returned stake will be associated with pool[1] and this code will never query or sendrecover_staketo that address. This can leave funds unrecovered. For TONCore routers, recovery should iterateinner_pools()(and compute/send recover per pool address) or otherwise track the last staking target per election and use that address for returned-stake queries and recover messages.
async fn recover_stake(&mut self, node_id: &str) -> anyhow::Result<u64> {
let elector_addr = self.elector.address().await;
let node = self.nodes.get_mut(node_id).expect("node not found");
let elections_addr = node.pool_addr_cache.as_ref().cloned().unwrap_or(elector_addr);
let amount = self.elector.compute_returned_stake(&node.stake_addr().await).await?;
node.last_recover_amount = amount;
if amount > 0 {
tracing::info!(
"node [{}] send recover stake: amount={} TON",
node_id,
amount as f64 / 1_000_000_000.0
);
let fee = RECOVER_FEE + WALLET_COMPUTE_FEE;
let wallet_balance = node.wallet_balance().await?;
if wallet_balance < fee {
anyhow::bail!(
"node [{}] low wallet balance: required={} TON, available={} TON",
node_id,
fee as f64 / 1_000_000_000.0,
wallet_balance as f64 / 1_000_000_000.0
);
}
let msg_boc = write_boc(
&node
.wallet
.message(
elections_addr,
RECOVER_FEE,
Self::build_recover_stake_payload().await?,
)
.await?,
)?;
node.api.send_boc(&msg_boc).await?;
}
Ok(amount)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| match (&slot.address, &slot.params) { | ||
| (Some(addr), _) => addr.parse::<MsgAddressInt>().context("invalid pool address"), | ||
| (None, Some(params)) => { | ||
| let resolved = resolve_toncore_pool( | ||
| validator_addr, | ||
| params.validator_share, | ||
| None, | ||
| Some(params.max_nominators), | ||
| Some(params.min_validator_stake), | ||
| Some(params.min_nominator_stake), | ||
| )?; | ||
| Ok(resolved.address) | ||
| } |
There was a problem hiding this comment.
resolve_pool_address_from_config() does not validate TONCore slots where both address and params are present: the (Some(addr), _) arm parses and returns the address without checking it matches the deterministic address derived from params + validator_addr. This can cause CLI actions (deposit/withdraw/deploy targeting) to operate on the wrong contract even though runtime validation (resolve_toncore_pool) would reject it. Consider handling (Some(addr), Some(params)) by calling resolve_toncore_pool(..., Some(addr), ...) (or comparing derived vs explicit) and returning the validated derived address.
| let pool_name = binding | ||
| .pool | ||
| .as_ref() | ||
| .ok_or_else(|| anyhow::anyhow!("Binding '{}' has no pool configured", self.binding))?; | ||
| let pool_cfg = config | ||
| .pools | ||
| .get(pool_name) | ||
| .ok_or_else(|| anyhow::anyhow!("Pool '{}' not found", pool_name))?; | ||
|
|
||
| let (wallet_address, wallet_info_data, wallet_secret) = | ||
| wallet_info(rpc_client.clone(), wallet_cfg, vault.clone()).await?; | ||
|
|
||
| if wallet_info_data.account_state | ||
| != ton_http_api_client::v2::data_models::AccountState::Active | ||
| { | ||
| anyhow::bail!("Wallet '{}' is {}", binding.wallet, wallet_info_data.account_state); | ||
| } | ||
|
|
||
| let pool_slot = toncore_pool_slot_from_cli_flags(self.pool_even, self.pool_odd); | ||
| let pool_address = resolve_pool_address_from_config(pool_cfg, &wallet_address, pool_slot)?; | ||
|
|
There was a problem hiding this comment.
deposit-validator is documented/implemented as a TONCore-only operation, but this code will happily resolve an SNP pool address and then send the TONCore deposit_validator opcode to it (and attach the deposit amount). This can lead to failed transactions and potentially lost funds (depending on bounce behavior). Add an explicit check that pool_cfg is PoolConfig::TONCore { .. } before resolving/sending.
| for (node_id, pool_binding) in self.pools.iter() { | ||
| let inner = pool_binding.inner_pools(); | ||
| let pools = if inner.is_empty() { vec![pool_binding.clone()] } else { inner }; | ||
| for pool in pools { | ||
| let pool_data = match pool.get_pool_data().await { | ||
| Ok(d) => d, | ||
| Err(e) => { | ||
| tracing::warn!( | ||
| target: "contracts", | ||
| "[{}] get_pool_data error (skipping update_validator_set): pool={} {:#}", | ||
| node_id, pool.address().await, e | ||
| ); | ||
| continue; | ||
| } | ||
| }; | ||
|
|
||
| tracing::info!( | ||
| target: "contracts", | ||
| "[{}] pool={} state={} vsc_count={}", | ||
| node_id, pool.address().await, pool_data.state, pool_data.validator_set_changes_count | ||
| ); | ||
|
|
||
| if pool.storage_reserve() <= SNP_STORAGE_RESERVE | ||
| || pool_data.state != 2 | ||
| || pool_data.validator_set_changes_count >= 2 | ||
| { | ||
| continue; |
There was a problem hiding this comment.
ensure_pool_validator_sets_updated() calls pool.get_pool_data() for every pool, then skips SNP pools based on storage_reserve() <= SNP_STORAGE_RESERVE. This means every SNP node will still incur an RPC call each tick even though it can never need update_validator_set. Consider checking storage_reserve() (or pool kind) before calling get_pool_data() to avoid unnecessary load on TON HTTP API and reduce tick latency.
| for (i, (pool_address, state_init)) in deploy_targets.iter().enumerate() { | ||
| res.borrow_mut().address = pool_address.to_string(); | ||
|
|
||
| if pool_info.state == AccountState::Active { | ||
| if self.verbose { | ||
| println!("The pool '{}' is already deployed", &pool_address); | ||
| println!("Update pool info [{}/{}] ...", i + 1, deploy_targets.len()); | ||
| } |
There was a problem hiding this comment.
DeployPoolResult only contains a single address, but deploy pool can deploy multiple TONCore targets in one run (deploy_targets loop). The loop overwrites res.address each iteration, so the JSON output will only reflect the last pool and hide that multiple pools may have been deployed/skipped. Either restrict the command to a single target per invocation (require --pool-even/--pool-odd when dual is configured) or extend the result type to return a list/map of deployed pool addresses and states.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ture/sma-4-add-nominator-pool-support
…extra storage fees
Summary
Closes SMA-4